Skip to content

[FE] 대시보드 지표 카드 드래그앤드롭 편집 기능 구현#276

Merged
lwjmcn merged 33 commits intodevelopfrom
feature/#54-fe-dashboard-edit-draggable
Feb 19, 2026
Merged

[FE] 대시보드 지표 카드 드래그앤드롭 편집 기능 구현#276
lwjmcn merged 33 commits intodevelopfrom
feature/#54-fe-dashboard-edit-draggable

Conversation

@lwjmcn
Copy link
Collaborator

@lwjmcn lwjmcn commented Feb 15, 2026

#️⃣ 변경 사항

대시보드 카드 편집 기능에 드래그 앤 드롭(Drag & Drop) 시스템을 도입하고, 효율적인 카드 배치를 위한 상태 관리 방식을 리팩터링했습니다. 기존의 2차원 배열 기반 그리드 관리 방식에서 카드 배열 기반의 좌표 관리 방식으로 전환하였으며, 카드 간 충돌 시 자동으로 위치를 조정하는 밀어내기 알고리즘을 구현했습니다.

#️⃣ 작업 상세 내용

  • 드래그 앤 드롭(Drag & Drop) 핵심 로직 구현

    • useDragAndDropCard 커스텀 훅을 신설하여 드래그 시작, 드래그 오버, 드롭 등 전반적인 D&D 이벤트 핸들러 통합 관리
    • dragOver 이벤트에 스로틀링(Throttling)을 적용하여 성능 최적화
    • 카드 리스트에서 그리드로의 추가, 그리드 내 이동, 그리드에서 리스트로 드롭 시 삭제 기능 구현
  • 카드 밀어내기 재귀 알고리즘 구현

    • getPushedLayout: 드래그 중인 카드와 충돌하는 기존 카드들을 진입 방향에 따라 재귀적으로 밀어내는 알고리즘 구현
    • getPushDirectionPriority: 드래그 중인 카드의 중심점과 충돌 카드의 위치를 비교하여 밀어낼 방향의 우선순위 결정
  • 상태 관리 및 데이터 구조 리팩터링

    • EditCardProvider: 2차원 배열((MetricCardCode | null)[][]) 대신 카드 정보 객체 배열(DashboardCard[])로 상태 관리 방식 변경
    • EditCardContext: 드래그 상태(dragState), 고스트 상태(ghost), 임시 레이아웃(tempLayout) 관리를 위한 필드 추가 및 gridRef를 통한 좌표 계산 기반 마련
  • UI/UX 및 스타일링 개선

    • MiniViewGhost: 카드가 놓일 위치를 미리 보여주는 고스트 컴포넌트 추가 및 유효성(배치 가능 여부)에 따른 스타일링
    • useGridCellSize: 컨테이너 크기를 기반으로 각 그리드 셀의 픽셀 좌표와 크기를 계산하는 훅 구현
    • 이미지 드래그 방지 및 카드 드래그 시 텍스트 선택(select-none) 방지 처리
    • 메트릭 카드 이름 오타 수정 및 긴 텍스트 줄바꿈(break-keep) 스타일링 적용
  • 기타 개선 사항

    • PlusIconButton, TrashCanIconButton: 클릭 이벤트 전파 방지(stopPropagation) 처리
    • useEditCard 훅의 카드 추가/삭제 로직을 새로운 데이터 구조에 맞춰 수정

카드 밀어내기 알고리즘 플로우

플로우 차트 확인
    flowchart TD
        %% 최상단 시작점
        Start([시작]) --> DragEvent["'onDragOver' 이벤트 수신"]
    
        subgraph "1. 전처리 단계"
            DragEvent --> Throttle["'Throttle' 적용 (100ms)"]
            Throttle --> CalcPos["'calculateGridPos': 마우스 → 그리드 {row, col} 변환"]
            CalcPos --> GhostCheck{"'ghost' 위치가 변경되었는가?"}
        end
    
        GhostCheck -- "아니오" --> End([종료])
        GhostCheck -- "예" --> InitSim["시뮬레이션 레이아웃 구성<br/>(리스트 추가 시 새 카드 포함)"]
    
        subgraph "2. 재귀적 밀어내기 (getPushedLayout)"
            InitSim --> SetMoved["현재 카드를 'movedCards'에 추가"]
            SetMoved --> FindConflict["'getConflictingCards': 충돌 카드 검색"]
            FindConflict --> HasConflict{충돌 발생?}
            
            HasConflict -- "예" --> GetPriority["진입방향별 밀어낼 방향<br/>우선순위 결정<br/>({dx,dy}[]) getPushDirectionPriority"]
            GetPriority --> DirLoop["우선순위 방향 순회 시도"]
    
            %% 좌표 계산 로직 강조 섹션
            subgraph "좌표 계산 상세 (Next Position)"
                DirLoop --> CalcNext["'conflictNextX/Y' 계산 시작"]
                CalcNext --> XDir{dx 방향 확인}
                XDir -- "dx > 0 (우측으로 밀어내기)" --> SetNextX_R["'conflictNextX' = current.colNo + current.sizeX"]
                XDir -- "dx < 0 (좌측으로 밀어내기)" --> SetNextX_L["'conflictNextX' = current.colNo - conflict.sizeX"]
                XDir -- "dx = 0" --> YDir
                
                SetNextX_R --> YDir{dy 방향 확인}
                SetNextX_L --> YDir
                
                YDir -- "dy > 0 (하단으로 밀어내기)" --> SetNextY_D["'conflictNextY' = current.rowNo + current.sizeY"]
                YDir -- "dy < 0 (상단으로 밀어내기)" --> SetNextY_U["'conflictNextY' = current.rowNo - conflict.sizeY"]
                YDir -- "dy = 0" --> BoundaryCheck
                
                SetNextY_D --> BoundaryCheck
                SetNextY_U --> BoundaryCheck
            end
    
            BoundaryCheck{"그리드 경계 내에 있는가?<br/>(1 <= X < COL / 1 <= Y < ROW)"}
            
            BoundaryCheck -- "예" --> RecursiveCall["'getPushedLayout' 재귀 호출<br/>(변경된 좌표의 conflictCard 기준)"]
            
            RecursiveCall --> Success{결과 유효?}
            Success -- "예" --> FinalConflict["최종 충돌 여부 재확인"]
            
            %% 실패 시 루프 백
            Success -- "아니오" --> DirLoop
            BoundaryCheck -- "아니오" --> DirLoop
        end
    
        HasConflict -- "아니오" --> FinalConflict
        
        subgraph "3. 최종 반영"
            FinalConflict --> ApplyLayout["'setTempLayout(cards)' 반영"]
            ApplyLayout --> SetGhost["'setGhost(isValid)' 업데이트"]
        end
    
        SetGhost --> End
    
        %% 스타일링
        style Start fill:#f3f4f6,stroke:#374151,stroke-width:2px
        style End fill:#f3f4f6,stroke:#374151,stroke-width:2px
        style RecursiveCall fill:#eff6ff,stroke:#1d4ed8,stroke-dasharray: 5 5
        style CalcNext fill:#fff7ed,stroke:#c2410c,stroke-width:2px
Loading

드래그 이벤트 핸들러 동작 시퀀스

sequenceDiagram
    title "드래그앤드랍 & 밀어내기 상세 시퀀스"

    participant User as "사용자"
    participant CardList as "CardEditView (리스트)"
    participant MiniView as "MiniView (그리드)"
    participant Hook as "useDragAndDropCard (훅)"
    participant Context as "EditCardContext (상태)"
    participant PushAlgo as "getPushedLayout (알고리즘)"

    rect rgb(240, 255, 240)
    Note over User, CardList: [시작] 리스트 또는 그리드에서 드래그 시작
    alt "리스트에서 시작"
        User->>CardList: "'dragstart' (새 카드 선택)"
        CardList->>Hook: "'handleDragStart(e, LIST, card)'"
    else "그리드에서 시작"
        User->>MiniView: "'dragstart' (기존 카드 선택)"
        MiniView->>Hook: "'handleDragStart(e, GRID, card)'"
        Hook->>Context: "해당 카드의 'z-index' 낮춤 (숨김 처리)"
    end
    Hook->>Context: "'setDragState({ sourceArea, draggingCard, centerOffset })'"
    end

    loop "그리드 위에서 드래그 ('throttledHandleGridDragOver')"
        User->>MiniView: "'dragover' 이벤트 지속 발생"
        MiniView->>Hook: "'handleGridDragOver(e)'"
        Hook->>Hook: "'calculateGridPos' (마우스-셀 센터 비교)"
        
        Hook->>PushAlgo: "'getPushedLayout(currentLayout, ghostCandidate, ...)'"
        PushAlgo->>PushAlgo: "재귀적 충돌 계산 및 'PushPriority' 적용"
        PushAlgo-->>Hook: "결과: { cards, isValid }"
        
        Hook->>Context: "'setTempLayout(cards)', 'setGhost({row, col, isValid})'"
        Context-->>MiniView: "임시 레이아웃 및 'ghost' 시각화"
    end

    rect rgb(255, 245, 245)
    Note over User, CardList: [삭제 환경] 그리드 카드를 리스트로 드래그
    User->>CardList: "'dragenter' (리스트 영역 진입)"
    CardList->>Hook: "'handleListDragEnter()'"
    Hook->>Context: "'setIsOverList(true)' -> '삭제하기' 오버레이 표시"
    end

    alt "그리드에 드롭 (이동 또는 추가)"
        User->>MiniView: "'drop' 이벤트 발생"
        MiniView->>Hook: "'handleGridDrop(e)'"
        Hook->>Context: "조건: 'ghost.isValid' 이면 'setPlacedCards(tempLayout)'"
    else "리스트에 드롭 (삭제)"
        User->>CardList: "'drop' 이벤트 발생"
        CardList->>Hook: "'handleListDrop(e)'"
        Hook->>Context: "조건: 'sourceArea === GRID' 이면 'setPlacedCards'에서 필터링 삭제"
    end

    Note over Hook, Context: [종료] 모든 드래그 상태 초기화
    Hook->>Context: "'handleDragEnd()': dragState/ghost/tempLayout/isOverList 초기화"
Loading

리뷰 참고사항

  • 지난 PR과 비교해 크게 변경된 부분이 있어 따로 남깁니다.
  • 트랜지션 애니메이션을 위해 그리드 셀을 absolute로 두는 것으로 바꾸었습니다.
  • 2차원 행렬에서 충돌되는 카드를 확인하고 이를 재귀적으로 옮기는 데에 어려움이 있어, context에서 grid: MetricCardCode[][] 대신 placedCards: DashboardCard[]를 관리하는 방식으로 변경하였습니다.
  • 마우스 위치와 그리드 셀 위치 간 상관관계를 구하는 데 있어서, 그리드 셀 사이즈가 반응형이면 코드 복잡도가 너무 증가할 것 같아 그리드 셀 사이즈를 고정했습니다.

#️⃣ 관련 이슈

📸 스크린샷 (선택)

2026-02-16.3.58.09.mov

📎 참고할만한 자료 (선택)

위키 작성 중에 있습니다. 아주 러프하게 써두었는데 트러블슈팅 목록을 정리해두었으니 궁금하시면 참고해주세요.

- EditCardProvider에 드래그앤드랍 관련 상태 추가
- 대시보드 편집 영역 상수 및 타입 정의
- 대시보드 카드 타입에 드래그 상태 및 고스트 상태 추가
- 드래그앤드랍에서 충돌 로직을 계산할 때 grid 배열 시 코드가 복잡해지기 때문에 수정함
- 대시보드에서 카드의 위치와 크기를 계산하는 훅을 추가함
- 카드를 absolute 포지션으로 배치하고, 마우스 위치를 통해 그리드 셀을 도출해내는 로직을 위해 필요
- PlusIconButton과 TrashCanIconButton에서 클릭 이벤트가 부모 요소로 전파되지 않도록 수정
- 사용자 경험 개선을 위해 버튼 클릭 시 의도한 동작만 수행하도록 함
- 카드 밀어내기 알고리즘 구현
- 그리드 셀에 카드 위치 계산 기능 추가
- 드래그 이벤트 핸들러 구현
- dx, dy로 된 방향 객체 리터럴 선언
- 카드 편집 뷰에 드래그 앤 드롭 이벤트 핸들러 추가
- 미니 뷰에 드래그 앤 드롭 관련 상태 및 핸들러 통합
- 드래그 중인 카드의 시각적 피드백을 위한 Ghost 컴포넌트 추가
- 스로틀링을 통해 불필요한 함수 호출 방지
- 새로운 스로틀 유틸리티 함수 추가
@lwjmcn lwjmcn added the ✨ feat 새로운 기능이나 서비스 로직을 추가합니다. label Feb 15, 2026
Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

대시보드 카드 편집을 위한 드래그앤드롭 기능 구현을 확인했습니다. 2차원 배열로 관리되던 그리드 상태를 카드 객체 배열(placedCards)로 변경한 점은 데이터 모델링 관점에서 매우 훌륭한 리팩토링입니다. 이를 통해 상태 관리가 더 명확해지고 확장성이 개선되었습니다. 카드 밀어내기(push) 로직을 포함한 드래그앤드롭 구현은 복잡하지만, 관련 상태와 로직을 커스텀 훅(useDragAndDropCard, useGridCellSize)으로 분리하여 구조적으로 잘 설계되었습니다. 다만, useGridCellSize 훅에서 그리드의 크기를 하드코딩한 부분은 잠재적인 문제를 야기할 수 있어 개선이 필요합니다. 해당 부분에 대한 구체적인 피드백을 리뷰 코멘트로 남겼습니다.

@lwjmcn
Copy link
Collaborator Author

lwjmcn commented Feb 15, 2026

마우스 위치와 그리드 셀 위치 간 상관관계를 구하는 데 있어서, 그리드 셀 사이즈가 반응형이면 코드 복잡도가 너무 증가할 것 같아 그리드 셀 사이즈를 고정했습니다. 이 부분에 대한 의견 부탁 드립니다.

@lwjmcn
Copy link
Collaborator Author

lwjmcn commented Feb 15, 2026

백엔드 카드 위치 DTO가 rowNo, colNo인데 프론트엔드 메트릭 카드 상수에서 사이즈는 sizeX, sizeY여서 로직 상에 row/col과 x/y가 혼용되어 있습니다. 보기에 불편하지 않는지 의견 부탁 드립니다.

@lwjmcn
Copy link
Collaborator Author

lwjmcn commented Feb 15, 2026

메모이제이션 최적화가 적용되지 않은 상태입니다. 따라서 ghost 렌더링마다 전체 목록이 리렌더링 되고 있습니다. 이후에 이슈를 새로 파서 작업할 생각인데 지금 작업이 필요한지에 대해 의견 부탁 드립니다.

@lwjmcn lwjmcn self-assigned this Feb 15, 2026
Copy link
Collaborator

@lee0jae330 lee0jae330 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

복잡한 기능 구현하시느라 고생하셨습니다 !! 군데군데 주석처리된 부분 지워주세요 ! (디버깅용 console등)

Comment on lines +23 to +38
<EditCardContext.Provider
value={{
initPlacedCards,
placedCards,
setPlacedCards,
gridRef,
dragState,
setDragState,
ghost,
setGhost,
tempLayout,
setTempLayout,
isOverList,
setIsOverList,
}}
>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3: 이젠 진짜 state Provider, action Provider로 나누어야 할 것 같네요..
useReducer를 사용해서 state provider랑 dispatch provider를 분리하면 좋을 것 같습니다
아니면 context를 쪼갠다거나...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 dispatch 함수만을 구독하고 있는 컴포넌트가 없습니다 (다 state만 구독하거나 state & dispatch를 동시에 구독하고 있어요)
그래서 state provider와 dispatch provider를 분리해서 얻을 이점이 딱히 없을 것 같습니다.
지금 보기에도 context에 상태가 너무 많아서 분리해야 할 필요성은 있을 것 같습니다. 일단 카드 그리드 상태와 드래그앤드랍 상태를 나눌 수 있을 것 같아요. 다만 카드목록 전체 리렌더링 상황에서도 selector를 고려했어서, 대시보드 카드 관련 내용을 다 외부 스토어로 전환하는 게 어떤가 싶습니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리팩터링 이슈 파두었습니다. #316

@lee0jae330
Copy link
Collaborator

마우스 위치와 그리드 셀 위치 간 상관관계를 구하는 데 있어서, 그리드 셀 사이즈가 반응형이면 코드 복잡도가 너무 증가할 것 같아 그리드 셀 사이즈를 고정했습니다. 이 부분에 대한 의견 부탁 드립니다.

이 부분은 좋은데, 브라우저 너비가 작아지면, overflow-hidden처리는 문제가 있어서 해상도 가드를 적용하거나 overflow-scroll이 되어야할 것 같네요

@lee0jae330
Copy link
Collaborator

메모이제이션 최적화가 적용되지 않은 상태입니다. 따라서 ghost 렌더링마다 전체 목록이 리렌더링 되고 있습니다. 이후에 이슈를 새로 파서 작업할 생각인데 지금 작업이 필요한지에 대해 의견 부탁 드립니다.

최적화가 되면 좋을 것 같긴 하네요...
심각한 성능저하가 있을까요 ??

@lee0jae330
Copy link
Collaborator

백엔드 카드 위치 DTO가 rowNo, colNo인데 프론트엔드 메트릭 카드 상수에서 사이즈는 sizeX, sizeY여서 로직 상에 row/col과 x/y가 혼용되어 있습니다. 보기에 불편하지 않는지 의견 부탁 드립니다.

이건 추후 맞추는것으로 할까요 ?? 기능 구현부터 해야할 것 같네요....

@lwjmcn
Copy link
Collaborator Author

lwjmcn commented Feb 18, 2026

메모이제이션 최적화가 적용되지 않은 상태입니다. 따라서 ghost 렌더링마다 전체 목록이 리렌더링 되고 있습니다. 이후에 이슈를 새로 파서 작업할 생각인데 지금 작업이 필요한지에 대해 의견 부탁 드립니다.

최적화가 되면 좋을 것 같긴 하네요... 심각한 성능저하가 있을까요 ??

아직 눈에 띄는 문제는 없습니다. 카드 목록에 컴포넌트들이 다 들어오면 문제가 좀 있을 수 있겠네요. 리팩터링 이슈 파두겠습니다!

@mskwon02
Copy link
Collaborator

백준 알고리즘 문제 같네요...ㅎㅎ 브랜치 fetch 땡겨서 직접 해봤는데 UX적으로 완성도 높은 구현이라는 생각이 들었습니다! 너무 수고하셨습니다!!

@lwjmcn
Copy link
Collaborator Author

lwjmcn commented Feb 18, 2026

마우스 위치와 그리드 셀 위치 간 상관관계를 구하는 데 있어서, 그리드 셀 사이즈가 반응형이면 코드 복잡도가 너무 증가할 것 같아 그리드 셀 사이즈를 고정했습니다. 이 부분에 대한 의견 부탁 드립니다.

이 부분은 좋은데, 브라우저 너비가 작아지면, overflow-hidden처리는 문제가 있어서 해상도 가드를 적용하거나 overflow-scroll이 되어야할 것 같네요

overflow-scroll 적용했습니다.

Copy link
Collaborator

@lee0jae330 lee0jae330 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

굿 ~

@lwjmcn lwjmcn merged commit dcfe9c7 into develop Feb 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ feat 새로운 기능이나 서비스 로직을 추가합니다.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FE] 3-3-5. 대시보드 미니뷰 편집

3 participants

Comments